Skip to content

feat: Toast support for leadingIcon and update default styles#764

Merged
rohanchkrabrty merged 7 commits intomainfrom
worktree-toast-leadingicon-and-styles
May 5, 2026
Merged

feat: Toast support for leadingIcon and update default styles#764
rohanchkrabrty merged 7 commits intomainfrom
worktree-toast-leadingicon-and-styles

Conversation

@rohanchkrabrty
Copy link
Copy Markdown
Contributor

Summary

  • Add leadingIcon prop to toastManager.add/update and Toast.createToastManager, lifting it onto Base UI's typed data slot via a wrapper.
  • Map success/error/warning/info/loading toast types to default Radix icons (with apsara Spinner for loading); explicit leadingIcon still wins.
  • Restructure toast layout to match Figma: header row (icon + title + actions) and a separate description row indented to align under the title; description color updated to foreground-base-primary.
  • Title-only toasts render with the description text style so they don't look outsized; only the icon is colored by type — the surface stays neutral.
  • Replace the static demo at the top of the toast docs with an interactive playground (title, description, type, actionButton toggle) and add tests for the new behaviors.

rohanchkrabrty and others added 5 commits April 30, 2026 00:20
- Add `leadingIcon` prop to toastManager.add/update and Toast.createToastManager
  by lifting it onto Base UI's typed `data` slot via a wrapper.
- Render the leading icon before the title; color is driven by toast `type`
  (success/error/warning/info), the toast container itself stays neutral.
- Drop typed background/border/text-color overrides so success/error/info/
  warning toasts share the default surface — only the icon color changes.
- Tighten content alignment: center title-only toasts, top-align when a
  description is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Map success/error/warning/info/loading toast types to Radix icons
(CheckCircled / CrossCircled / ExclamationTriangle / InfoCircled) and
the apsara Spinner for loading. Untyped toasts fall back to
InfoCircledIcon with the existing base-secondary color. Explicit
`leadingIcon` still wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Description text color: base-secondary -> base-primary
  (Figma node 3594:25050 specifies foreground-base-primary).
- Restructure content into a header row (icon + title + actions)
  followed by a separate description row indented 24px (rs-space-7),
  matching the Figma column layout instead of stacking title/desc
  next to the icon.
- Header row gets gap=5 between left and actions, gap=3 between
  icon and title; min-height = rs-space-7 to keep title-only toasts
  consistent.
- Title now always uses .title styling (was swapping to .description
  styling for title-only toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a toast has a title but no description, render the title with
.description style (12px regular) rather than .title style (14px medium)
so the toast doesn't look outsized for a single short message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a playground demo with controls for title, description, type, and
an actionButton boolean toggle. Empty title/description strings are
omitted from the toastManager.add call so users can test partial
configurations. Replaces the static preview Demo at the top of the
toast docs page with the playground.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview, Comment May 4, 2026 9:17pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

This pull request enhances the Toast component system by introducing a leadingIcon feature that renders custom or type-driven icons before toast titles, adds a useToastManager() hook for accessing toast manager methods and reactive toast state from within components, and introduces a createToastManager() factory function for scoped manager instances. The Toast provider architecture is refactored to accept an optional toastManager prop for customization, while internal option handling transforms the new leadingIcon API into Base UI's underlying data structure. Component styling is updated to remove type-based background coloring from the toast container itself, instead applying type-driven colors only to leading icons, and the layout is restructured with new CSS classes to accommodate the icon slot. Documentation, tests, and example pages are updated to demonstrate the new capabilities.

Suggested reviewers

  • rohilsurana
  • rsbh
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: adding leadingIcon support and updating default toast styles to match the Figma design.
Description check ✅ Passed The description is directly related to the changeset, providing a clear summary of the leadingIcon prop addition, icon mapping by type, layout restructuring, and new demos/tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@rohanchkrabrty rohanchkrabrty changed the title feat(toast): leading icons and Figma-aligned styles feat: Toast support for leadingIcon and update styles Apr 29, 2026
@rohanchkrabrty rohanchkrabrty changed the title feat: Toast support for leadingIcon and update styles feat: Toast support for leadingIcon and update default styles Apr 29, 2026
Comment thread packages/raystack/components/toast/toast-root.tsx Outdated
Comment thread apps/www/src/content/docs/components/toast/props.ts Outdated
Comment thread packages/raystack/components/toast/toast.tsx Outdated
Comment thread packages/raystack/components/toast/toast-manager.ts Outdated
Comment thread packages/raystack/components/toast/toast-root.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/www/src/content/docs/components/toast/props.ts (1)

20-33: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document the new toastManager provider prop.

ToastProviderProps still omits toastManager, even though packages/raystack/components/toast/toast-provider.tsx now accepts it. That means the docs won't surface the new scoped-manager API added in this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/toast/props.ts` around lines 20 - 33,
The ToastProviderProps interface is missing the new toastManager prop that
toast-provider.tsx now accepts; add a documented optional toastManager property
to ToastProviderProps (matching the type used/expected by the provider in
packages/raystack/components/toast/toast-provider.tsx) and include a JSDoc
comment explaining it provides a scoped manager for adding/removing toasts so
the docs surface the new scoped-manager API. Ensure the prop name is exactly
toastManager and its type aligns with the provider's expected manager
interface/constructor.
🧹 Nitpick comments (1)
apps/www/src/content/docs/components/toast/demo.ts (1)

338-360: ⚡ Quick win

Inline component definition in hookDemo teaches a React anti-pattern.

Inner is defined inside HookDemo, so React sees a brand-new function reference on every render of HookDemo and unmounts + remounts Inner every time. Users copying this snippet verbatim will carry the pattern into production.

The comment // Hook usage lives in an inner component so it runs inside the Provider. is correct in intent; moving Inner outside preserves the teaching point while being idiomatic.

♻️ Proposed fix
 export const hookDemo = {
   type: 'code',
   code: `
+  function Inner() {
+    const { add, toasts } = useToastManager();
+    return (
+      <Flex direction="column" gap="medium">
+        <Button onClick={() => add({
+            title: "Triggered via hook",
+            description: "Same leadingIcon-aware API as the singleton manager.",
+            type: "success"
+          })}>
+          Show toast
+        </Button>
+        <span>Active toasts: {toasts.length}</span>
+      </Flex>
+    )
+  }
+
   function HookDemo() {
-    // Hook usage lives in an inner component so it runs inside the Provider.
-    function Inner() {
-      const { add, toasts } = useToastManager();
-      return (
-        <Flex direction="column" gap="medium">
-          <Button onClick={() => add({
-              title: "Triggered via hook",
-              description: "Same leadingIcon-aware API as the singleton manager.",
-              type: "success"
-            })}>
-            Show toast
-          </Button>
-          <span>Active toasts: {toasts.length}</span>
-        </Flex>
-      )
-    }
+    // Inner is a sibling component so it can use useToastManager() inside the Provider.
     return (
       <Toast.Provider>
         <Inner />
       </Toast.Provider>
     )
   }`
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/toast/demo.ts` around lines 338 - 360,
The Inner component should be moved out of HookDemo to avoid recreating the
function on every render; define Inner as a top-level component that calls
useToastManager (keeping the same JSX and Button handler) and then have HookDemo
simply return <Toast.Provider><Inner /></Toast.Provider>; ensure Inner still
uses the same identifiers (Inner, HookDemo, useToastManager, Toast.Provider) so
the demo behavior and hook usage inside the provider remain identical while
eliminating the inline-component anti-pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/www/src/content/docs/components/toast/demo.ts`:
- Around line 9-11: The generated code snippets are malformed when title or
description contain quotes/backslashes; change the string interpolation in the
opts.push calls so the values are safely escaped (e.g., use
JSON.stringify(title) and JSON.stringify(description) instead of
`"${title}"`/`"${description}"`) when building the options array (the lines that
call opts.push for title and description), so the produced snippet contains a
properly escaped JS string.

In `@apps/www/src/content/docs/components/toast/index.mdx`:
- Around line 45-53: The docs import wrongly assumes a top-level named export
createToastManager from '@raystack/apsara'; fix by either updating the example
to use the actual API (import { Toast } from '@raystack/apsara' and call
Toast.createToastManager()) or, if you prefer a top-level helper, add a named
re-export: export createToastManager from the toast implementation (where
createToastManager is defined) and re-export it from the package entry so
consumers can import { createToastManager } from '@raystack/apsara'; ensure the
symbol createToastManager is exported alongside Toast to keep both usages
working.

In `@packages/raystack/components/toast/__tests__/toast.test.tsx`:
- Around line 228-250: The tests around leading icons in toast need to assert
the correct contract of ToastRoot: when leadingIcon is omitted the component
should render the default icon, and when leadingIcon is explicitly null it
should not render the leading-icon slot; update the two specs that call
toastManager.add(...) to target the leading-icon slot explicitly (e.g. via the
test id or data attribute used for the slot such as "leading-icon" or a specific
slot selector) instead of using a broad aria-hidden selector, so the first test
expects the leading-icon element to exist and the null-case test asserts that
querying the explicit leading-icon element returns null/not present.

In `@packages/raystack/components/toast/toast-root.tsx`:
- Around line 59-67: Replace truthy boolean coercion with nullish checks for
ReactNode props: change hasBoth from "!!toast.title && !!toast.description" to
"toast.title != null && toast.description != null" and update any conditional
renders that use "title && ..." or "description && ..." (including the block
around lines 95-100) to use "title != null" / "description != null" so valid
values like 0 or '' still render; similarly, where code distinguishes
leadingIcon omission vs explicit opt-out, ensure checks distinguish undefined vs
null (use "userIcon === null" to opt-out and "userIcon === undefined" to fall
back) rather than truthy checks.

---

Outside diff comments:
In `@apps/www/src/content/docs/components/toast/props.ts`:
- Around line 20-33: The ToastProviderProps interface is missing the new
toastManager prop that toast-provider.tsx now accepts; add a documented optional
toastManager property to ToastProviderProps (matching the type used/expected by
the provider in packages/raystack/components/toast/toast-provider.tsx) and
include a JSDoc comment explaining it provides a scoped manager for
adding/removing toasts so the docs surface the new scoped-manager API. Ensure
the prop name is exactly toastManager and its type aligns with the provider's
expected manager interface/constructor.

---

Nitpick comments:
In `@apps/www/src/content/docs/components/toast/demo.ts`:
- Around line 338-360: The Inner component should be moved out of HookDemo to
avoid recreating the function on every render; define Inner as a top-level
component that calls useToastManager (keeping the same JSX and Button handler)
and then have HookDemo simply return <Toast.Provider><Inner /></Toast.Provider>;
ensure Inner still uses the same identifiers (Inner, HookDemo, useToastManager,
Toast.Provider) so the demo behavior and hook usage inside the provider remain
identical while eliminating the inline-component anti-pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4303d83f-d254-4174-ade2-e3a6fd36c146

📥 Commits

Reviewing files that changed from the base of the PR and between fddcf49 and 75d8103.

📒 Files selected for processing (14)
  • apps/www/src/app/examples/combobox/page.tsx
  • apps/www/src/content/docs/components/toast/demo.ts
  • apps/www/src/content/docs/components/toast/index.mdx
  • apps/www/src/content/docs/components/toast/props.ts
  • docs/V1-migration.md
  • packages/raystack/components/toast/__tests__/toast.test.tsx
  • packages/raystack/components/toast/index.ts
  • packages/raystack/components/toast/toast-manager.ts
  • packages/raystack/components/toast/toast-provider.tsx
  • packages/raystack/components/toast/toast-root.tsx
  • packages/raystack/components/toast/toast.module.css
  • packages/raystack/components/toast/toast.tsx
  • packages/raystack/index.tsx
  • packages/raystack/styles/primitives/z-index.css

Comment on lines +9 to +11
if (title && title !== '') opts.push(`title: "${title}"`);
if (description && description !== '')
opts.push(`description: "${description}"`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unescaped interpolation in getCode will produce malformed code snippets.

If a user types a double-quote in the title or description playground controls (e.g., He said "hello"), the generated snippet becomes title: "He said "hello"" — syntactically invalid JavaScript. A backslash has the same effect.

🛠️ Proposed fix
-  if (title && title !== '') opts.push(`title: "${title}"`);
-  if (description && description !== '')
-    opts.push(`description: "${description}"`);
+  const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+  if (title && title !== '') opts.push(`title: "${escape(title)}"`);
+  if (description && description !== '')
+    opts.push(`description: "${escape(description)}"`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (title && title !== '') opts.push(`title: "${title}"`);
if (description && description !== '')
opts.push(`description: "${description}"`);
const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
if (title && title !== '') opts.push(`title: "${escape(title)}"`);
if (description && description !== '')
opts.push(`description: "${escape(description)}"`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/toast/demo.ts` around lines 9 - 11, The
generated code snippets are malformed when title or description contain
quotes/backslashes; change the string interpolation in the opts.push calls so
the values are safely escaped (e.g., use JSON.stringify(title) and
JSON.stringify(description) instead of `"${title}"`/`"${description}"`) when
building the options array (the lines that call opts.push for title and
description), so the produced snippet contains a properly escaped JS string.

Comment on lines +45 to +53
```tsx
import { Toast, createToastManager } from '@raystack/apsara'

const manager = createToastManager()

<Toast.Provider toastManager={manager}>
<App />
</Toast.Provider>
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

createToastManager is not a named export — docs import will fail for users.

createToastManager is only accessible as Toast.createToastManager; neither packages/raystack/index.tsx nor packages/raystack/components/toast/index.ts expose it as a standalone named export. A user who copies this import will get a TypeScript/bundler error. The position demo code snippets (e.g., const manager = Toast.createToastManager()) already use the correct form.

Either fix the docs or add createToastManager to the named exports.

📝 Option A — Fix the docs to match the actual API
-```tsx
-import { Toast, createToastManager } from '@raystack/apsara'
-
-const manager = createToastManager()
-
-<Toast.Provider toastManager={manager}>
-  <App />
-</Toast.Provider>
-```
+```tsx
+import { Toast } from '@raystack/apsara'
+
+const manager = Toast.createToastManager()
+
+<Toast.Provider toastManager={manager}>
+  <App />
+</Toast.Provider>
+```
📝 Option B — Add the named export (if the design intent is a top-level export)

In packages/raystack/components/toast/index.ts:

-export { Toast, toastManager, useToastManager } from './toast';
+export { Toast, toastManager, useToastManager, createToastManager } from './toast';

And in packages/raystack/index.tsx:

-export { Toast, toastManager, useToastManager } from './components/toast';
+export { Toast, toastManager, useToastManager, createToastManager } from './components/toast';

Then in packages/raystack/components/toast/toast.tsx add the named re-export:

-export { toastManager, useToastManager } from './toast-manager';
+export { toastManager, useToastManager, createToastManager } from './toast-manager';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/toast/index.mdx` around lines 45 - 53,
The docs import wrongly assumes a top-level named export createToastManager from
'@raystack/apsara'; fix by either updating the example to use the actual API
(import { Toast } from '@raystack/apsara' and call Toast.createToastManager())
or, if you prefer a top-level helper, add a named re-export: export
createToastManager from the toast implementation (where createToastManager is
defined) and re-export it from the package entry so consumers can import {
createToastManager } from '@raystack/apsara'; ensure the symbol
createToastManager is exported alongside Toast to keep both usages working.

Comment on lines +228 to +250
it('does not render leading-icon slot when leadingIcon is omitted', async () => {
act(() => {
toastManager.add({ title: 'No icon' });
});

expect(await screen.findByText('No icon')).toBeInTheDocument();
expect(screen.queryByTestId('leading-icon')).not.toBeInTheDocument();
});

it('renders no icon when leadingIcon is explicitly null, even with a type default', async () => {
act(() => {
toastManager.add({
title: 'No icon success',
type: 'success',
leadingIcon: null
});
});

const toastEl = await screen.findByText('No icon success');
const root = toastEl.closest('[data-type="success"]');
expect(root).toBeInTheDocument();
// The leading-icon wrapper (the only aria-hidden span in the toast) should not render.
expect(root!.querySelector('[aria-hidden="true"]')).toBeNull();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

These assertions don't protect the actual leading-icon contract.

When leadingIcon is omitted, ToastRoot falls back to the default info icon, so the "does not render leading-icon slot" test is asserting the wrong behavior. Also, [aria-hidden="true"] is too broad for the null case and can match unrelated hidden elements inside the toast. Please target the leading-icon slot explicitly in both cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/toast/__tests__/toast.test.tsx` around lines 228
- 250, The tests around leading icons in toast need to assert the correct
contract of ToastRoot: when leadingIcon is omitted the component should render
the default icon, and when leadingIcon is explicitly null it should not render
the leading-icon slot; update the two specs that call toastManager.add(...) to
target the leading-icon slot explicitly (e.g. via the test id or data attribute
used for the slot such as "leading-icon" or a specific slot selector) instead of
using a broad aria-hidden selector, so the first test expects the leading-icon
element to exist and the null-case test asserts that querying the explicit
leading-icon element returns null/not present.

Comment on lines +59 to +67
// Promote description into the title slot when title is missing so the icon
// and headline sit on the same row. The second row only renders when both
// are present.
const title = toast.title ?? toast.description;
const hasBoth = !!toast.title && !!toast.description;
// `leadingIcon: undefined` (omitted) → fall back to the type default.
// `leadingIcon: null` → explicit opt-out, render nothing.
// anything else → use what the user provided.
const userIcon = (toast.data as ToastData | undefined)?.leadingIcon;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use nullish checks here instead of truthiness.

title/description are ReactNode, so valid falsy values like 0 or '' currently disappear: hasBoth becomes false and {title && ...} skips rendering entirely. Drive this with != null checks instead.

💡 Suggested fix
-  const title = toast.title ?? toast.description;
-  const hasBoth = !!toast.title && !!toast.description;
+  const hasTitle = toast.title != null;
+  const hasDescription = toast.description != null;
+  const title = hasTitle ? toast.title : toast.description;
+  const hasBoth = hasTitle && hasDescription;
@@
-              {title && (
+              {title != null && (
                 <ToastPrimitive.Title
                   className={hasBoth ? styles.title : styles.description}
                 >

Also applies to: 95-100

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/toast/toast-root.tsx` around lines 59 - 67,
Replace truthy boolean coercion with nullish checks for ReactNode props: change
hasBoth from "!!toast.title && !!toast.description" to "toast.title != null &&
toast.description != null" and update any conditional renders that use "title &&
..." or "description && ..." (including the block around lines 95-100) to use
"title != null" / "description != null" so valid values like 0 or '' still
render; similarly, where code distinguishes leadingIcon omission vs explicit
opt-out, ensure checks distinguish undefined vs null (use "userIcon === null" to
opt-out and "userIcon === undefined" to fall back) rather than truthy checks.

@rohanchkrabrty rohanchkrabrty merged commit ec75a0f into main May 5, 2026
5 checks passed
@rohanchkrabrty rohanchkrabrty deleted the worktree-toast-leadingicon-and-styles branch May 5, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants